第10章 函数

函数实际上是对象,每个函数都是 Function 类型的实例。因为函数时对象,所以函数名就是指向函数对象的指针,而且不一定与函数本身紧密绑定。函数通常以如下方式定义:

function sum(num1, num2) {
  return num1 + num2;
}

另一种定义方式是使用函数表达式:

// 注意,function 关键字后面没有名称,且末尾是有分号的
let sum = function(num1, num2) {
  return num1 + num2;
};

还可以通过“箭头函数”的方式定义函数:

let sum = (num1, num2) => {
  return num1 + num2;
}

最后一种定义方式(仅供了解,不推荐使用)是使用 Function 构造函数,构造函数接收任意多个字符串参数,最后一个参数会被当成函数体,而之前的参数都是函数的参数:

let sum = new Function("num1", "num2", "return num1 + num2");

箭头函数

ES6 新增了箭头函数的语法定义,通过箭头函数实例化的函数对象与普通函数表达式创建的函数对象行为是相同的,因为其简洁的语法非常适合嵌入函数的场景:

let ints = [1, 2, 3];
console.log(ints.map(function(i) { return i + 1; }));  // [2, 3, 4]
console.log(ints.map((i) => { return i + 1 }));        // [2, 3, 4]

如果只有一个参数,可以不用括号。只有没有参数,或者多个参数的情况下,才需要使用括号:

// 以下两种写法都有效
let double = (x) => { return 2 * x; }; 
let triple = x => { return 3 * x; };
// 没有参数需要括号
let getRandom = () => { return Math.random(); };
// 多个参数需要括号
let sum = (a, b) => { return a + b; };
// 无效的写法:
let multiply = a, b => { return a * b; };

如果箭头函数的函数体只有一行代码,可以省略函数体大括号,省略大括号会隐式返回这行代码的值:

// 以下两种写法都有效,而且返回相应的值
let double = (x) => { return 2 * x; }; 
let triple = (x) => 3 * x;
// 可以用于赋值
let value = {};
let setName = (x) => x.name = "Matt"; 
setName(value); 
console.log(value.name); // "Matt"

箭头函数虽然语法简洁,但也有很多场合不适用。箭头函数不能使用 arguments、super 和 new.target,也不能用作构造函数。此外,箭头函数也没有 prototype 属性。

函数名

因为函数名就是指向函数的指针,所以一个函数可以有多个名称:

function sum(num1, num2) {
  return num1 + num2;
}
console.log(sum(10, 10)); // 20
let anotherSum = sum;
console.log(anotherSum(10, 10));  // 20
sum = null;
console.log(anotherSum(10, 10));  // 20

ES6 的所有函数对象都会暴露一个只读的 name 属性,其中包含关于函数的信息,即使函数没有名称, 也会如实显示成空字符串。如果函数是一个 get 函数、set 函数,或者使用 bind() 实例化,那么标识符前面会加上一个前缀:

function foo() {}
console.log(foo.name); // foo
console.log(foo.bind(null).name); // bound foo
let dog = {
  years: 1,
  get age() {
    return this.years;
  },
  set age(newAge) {
    this.years = newAge;
  } 
}
let propertyDescriptor = Object.getOwnPropertyDescriptor(dog, 'age'); 
console.log(propertyDescriptor.get.name); // get age 
console.log(propertyDescriptor.set.name); // set age

参数

JS 函数既不关心传入的参数个数,也不关心这些参数的数据类型。例如定义函数时要接收两个参数,调用函数时可以传一个、三个,甚至一个也不传,解释器不会报错。这是因为 JS 函数的参数在内部表现为一个数组,在使用 function 关键字定义(非箭头)函数时,可以在函数内部访问 arguments 对象,从中取得传进来的每个参数值。对于命名参数而言,如果调用函数时没有传这个参数,那么它的值就是 undefined。

function howManyArgs() {
  console.log(arguments.length);
}
howManyArgs("string", 45);  // 2
howManyArgs();              // 0
howManyArgs(12);            // 1

如果函数是使用箭头语法定义的,那么传给函数的参数将不能使用 arguments 关键字访问,而只能通过定义的命名参数访问。

在函数定义中的参数后面用 = 就可以为参数赋一个默认值,调用函数时给参数传 undefined 相当于没有传值:

function makeKing(name = 'Henry', numerals = 'VIII') {
  return `King ${name} ${numerals}`;
}
console.log(makeKing()); // 'King Henry VIII'
console.log(makeKing('Louis')); // 'King Louis VIII'
console.log(makeKing(undefined, 'VI'));  // 'King Henry VI'

默认参数值并不限于原始值或对象类型,也可以使用调用函数返回的值:

let romanNumerals = ['I', 'II', 'III', 'IV', 'V', 'VI'];
let ordinality = 0;
function getNumerals() {
  // 每次调用后递增
  return romanNumerals[ordinality++];
}
function makeKing(name = 'Henry', numerals = getNumerals()) {
  return `King ${name} ${numerals}`;
}
console.log(makeKing()); // 'King Henry I'
console.log(makeKing('Louis', 'XVI'));  // 'King Louis XVI'
console.log(makeKing()); // 'King Henry II'
console.log(makeKing()); // 'King Henry III'

函数的默认参数只有在函数被调用时才会求值,不会在函数定义时求值。而且,计算默认值的函数只有在调用函数但未传相应参数时才会被调用。

函数的参数默认值是按顺序定义的,因此后定义的默认值参数可以引用先定义的参数,但反过来就不行

function makeKing(name = 'Henry', numerals = name) { 
  return `King ${name} ${numerals}`;
}    
console.log(makeKing()); // King Henry Henry
// 调用时不传第一个参数会报错
function makeKing(name = numerals, numerals = 'VIII') {
  return `King ${name} ${numerals}`;
}

参数扩展

ES6 新增了扩展操作符,使用它可以非常简洁地操作和组合集合数据。扩展操作符既可以用于调用函数时传参,也可以用于定义函数参数。

let values = [1, 2, 3, 4];
function getSum() {
  let sum = 0;
  for (let i = 0; i < arguments.length; ++i) {
    sum += arguments[i];
  }
  return sum; 
}

这个函数希望将所有加数逐个传进来,然后通过迭代 arguments 对象来实现累加。

// 不使用扩展操作符
console.log(getSum.apply(null, values)); // 10
// 使用扩展操作符
console.log(getSum(...values)); // 10
// 还可以使用扩展操作符传其他参数
console.log(getSum(-1, ...values)); // 9
console.log(getSum(...values, 5)); // 15
console.log(getSum(-1, ...values, 5)); // 14
console.log(getSum(...values, ...[5,6,7])); // 28

函数声明与函数表达式

JavaScript 引擎在任何代码执行之前,会先读取函数声明,并在执行上下文中生成函数定义。即使函数定义出现在调用它们的代码之后,引擎也会把函数声明提升到顶部。而函数表达式必须等到代码执行到它那一行,才会在上下文中生成函数定义。

// 没问题 
console.log(sum(10, 10)); 
function sum(num1, num2) {
  return num1 + num2;
}
// 会出错
console.log(sum(10, 10));
let sum = function(num1, num2) {
  return num1 + num2; 
};

函数作为值进行传递

可以把函数作为参数传给另一个函数,而且还可以在一个函数中返回另一个函数。

function callSomeFunction(someFunction, someArgument) {
  return someFunction(someArgument);
}

arguments、this、new.target

arguments 除了包含传给函数的参数外,还有一个 callee 属性,它指向 arguments 对象所在函数的指针,例如下面这个阶乘函数:

function factorial(num) {
  if (num <= 1) {
    return 1;
  } else {
    return num * factorial(num - 1);
  }
}

这个函数要正确执行就必须保证函数名是 factorial,从而导致了紧密耦合。使用 arguments.callee 就可以对其解耦:

function factorial(num) {
  if (num <= 1) {
    return 1;
  } else {
    // 内部逻辑不需要知道函数名是什么
    return num * arguments.callee(num - 1);
  } 
}
// 应用实例
let trueFactorial = factorial;
// 重新定义了 factorial 函数
factorial = function() {
  return 0;
};
console.log(trueFactorial(5));  // 120,这里可以正常执行递归操作
console.log(factorial(5));      // 0

this 在标准函数和箭头函数中有不同的行为。在标准函数中,this 引用的是把函数当成方法调用的上下文对象,在网页的全局上下文中调用函数时,this 指向 window

window.color = 'red';
let o = { color: 'blue' };
function sayColor() {
  console.log(this.color);
}
sayColor(); // 'red'
o.sayColor = sayColor;
o.sayColor(); // 'blue'

定义在全局上下文中的函数 sayColor() 引用了 this 对象,这个 this 到底引用哪个对象必须到函数被调用时才能确定。如果在全局上下文中调用 sayColor(),结果会输出“red”,因为 this 指向 window,而 this.color 相当于 window.color。而在把 sayColor() 赋值给 o 之后再调用 o.sayColor(),this 会指向 o,即 this.color 相当于 o.color,所以会显示“blue”。

在箭头函数中,this 引用的是定义箭头函数的上下文。

window.color = 'red';
let o = {
  color: 'blue'
};
let sayColor = () => console.log(this.color);
sayColor();    // 'red'
o.sayColor = sayColor;
o.sayColor();  // 'red'

上面的例子对 sayColor() 分别进行了两次调用,由于箭头函数是在 window 上下文中定义的,所以两次都返回了“red”

在回调事件或定时回调中调用某个函数时,this 指向的对象并非总是想要的对象,此时将回调函数写成箭头函数就可以解决问题,因为箭头函数中的 this 会保留定义该函数时的上下文:

function King() {
  this.royaltyName = 'Henry';
  // this 引用 King 的实例
  setTimeout(() => console.log(this.royaltyName), 1000);
}
function Queen() {
  this.royaltyName = 'Elizabeth';
  // this 引用 window 对象
  setTimeout(function() { console.log(this.royaltyName); }, 1000);
}

new King();  // Henry
new Queen(); // undefined

函数可以作为构造函数实例化一个新对象,也可以作为普通函数被调用。ES6 新增了 new.target 属性用来检测函数是否使用了 new 关键字进行调用。如果函数是正常调用,则 new.target 为 undefined,否则返回被调用的构造函数。

function King() {
  if (!new.target) {
    throw 'King must be instantiated using "new"'
  }
  console.log('King instantiated using "new"');
}

new King(); // King instantiated using "new"
King();     // Error: King must be instantiated using "new"

函数的属性与方法

本文一开始提到,函数实际上是对象,因此也有属性和方法。每个函数都有两个属性:length 和 prototype。其中,length 属性保存函数定义的命名参数的个数;prototype 是保存引用类型所有实例方法的地方,这意味着 toString()、valueOf()等方法实际上都保存在 prototype 上。

函数还有两个方法:apply() 和 call(),这两个方法都会以指定的 this 值来调用函数。apply() 方法接收两个参数:函数内 this 的值和一个参数数组。

function sum(num1, num2) {
  return num1 + num2;
}
function callSum1(num1, num2) {
  return sum.apply(this, arguments); // 传入 arguments 对象
}
function callSum2(num1, num2) {
  return sum.apply(this, [num1, num2]); // 传入数组
}
console.log(callSum1(10, 10));  // 20
console.log(callSum2(10, 10));  // 20

值得一提的是,在严格模式下,调用函数时如果没有指定上下文对象,则 this 值不会指向 window。除非使用 apply() 或 call() 把函数指定给一个对象,否则 this 的值会变成 undefined。

call() 方法与 apply() 的作用一样,只是传参的形式不同,只能将参数一个一个地列出来:

function sum(num1, num2) {
  return num1 + num2;
}
function callSum(num1, num2) {
  return sum.call(this, num1, num2);
}
console.log(callSum(10, 10)); // 20

apply() 和 call() 真正强大的地方不是给函数传参,而是控制函数调用上下文即函数体内 this 值的能力,套用之前的一个例子:

window.color = 'red';
let o = {
  color: 'blue'
};
function sayColor() {
  console.log(this.color);
}
sayColor(); // red
sayColor.call(this); // red
sayColor.call(window); // red
sayColor.call(o); // blue

使用 call() 或 apply() 的好处是可以将任意对象设置为任意函数的作用域,这样对象可以不用关心方法。

闭包

闭包指的是那些引用了另一个函数作用域中变量的函数,通常是在嵌套函数中实现的。例如:

function createComparisonFunction(propertyName) {
  return function(object1, object2) {
    // value1 和 value2 位于内部函数(匿名函数)中
    // 其中引用了外部函数的变量 propertyName
    let value1 = object1[propertyName];
    let value2 = object2[propertyName];
    if (value1 < value2) {
      return -1;
    } else if (value1 > value2) {
      return 1;
    } else {
      return 0;
    }
  };
}

let compare = createComparisonFunction('name');
let result = compare({ name: 'Nicholas' }, { name: 'Matt' });

在一个函数内部定义的函数会把其包含函数的活动对象添加到自己的作用域链中。因此,在 createComparisonFunction() 函数中,匿名函数的作用域链中实际上包含 createComparisonFunction() 的活动对象。在 createComparisonFunction() 返回匿名函数后,它的作用域链被初始化为包含 createComparisonFunction() 的活动对象和全局变量对象。这样,匿名函数就可以访问到 createComparisonFunction()可以访问的所有变量。闭包的副作用就是,createComparisonFunction() 的活动对象并不能在它执行完毕后销毁,因为匿名函数的作用域链中仍然有对它的引用。在 createComparisonFunction() 执行完毕后,其执行上下文的作用域链会销毁,但它的活动对象仍然会保留在内存中,直到匿名函数被销毁后才会被销毁:

// 创建比较函数
let compareNames = createComparisonFunction('name');
// 调用函数
let result = compareNames({ name: 'Nicholas' }, { name: 'Matt' });
// 解除对函数的引用,这样就可以释放内存了 
compareNames = null;